<?php

/**
 * ActiveRecord class 
 * 
 * A VERY simple implementation of ActiveRecord design pattern.
 * Any domain logic is intended to be implemented in child-classes.
 * 
 * Table methods are static (class-methods), while Row methods are instance-methods. 
 * 
 * Regardless of its simplicity, ActiveRecord has some pretty powerful features.
 * 
 * 1. It widely utilizes km::db::Select for returning the result sets, thus
 * allowing to change the fetching-mode afterwards, apply additional filters or
 * perform JOIN to other tables. Consider the following snippet:
 * 
 * $article = Article::find($id)
 * 	->join('JOIN author AS a ON (author.id = article.author_id)', 'a.name AS author_name')
 *  ->fetchOne();
 * // $article is a Article instance, with additional property "author_name"
 * 
 * 2. ActiveRecord may have some "knowledge" about the relations between the table in the database,
 * this is implemented with a help of static properties $children, $parents, $neighbors -
 * that represent 1-to-N, N-to-1 and N-to-M relationships respectively.
 * ActiveRecord only has an internal, protected API for retreiving related objects:
 * - getParent()
 * - getChildren()
 * - getNeighbors()
 * These methods are made protected in order to encourage users to create a more clean interface like:
 * 
 * class Article {
 * 		static protected $parents = array(
 *			'author' => array('Author', 'author_id')
 *		);
 * 		/**
 * 		 * @return Select
 * 		 *
 * 		function getAuthor()
 * 		{
 * 			return $this->getParent('author');
 * 		}
 * }
 * 
 * 3. ActiveRecord takes care of validating the data that are inserted/changed in the database:
 * it utilizes km::util::Validator
 * 
 * @author vbolshov
 */

namespace km\db;

use \Km;
use km\util\Validator;
use km\exception\NotFoundException;
use \ArrayAccess;
use \PDO;

class ActiveRecord implements ArrayAccess {
	/**
	 * @var PDO
	 */
	static protected $connection;
	/**
	 * @var string
	 */
	static protected $table, $pk = 'id', $sequence;
	/**
	 * @var array
	 */
	static protected $defaults;
	/**
	 * @var array
	 */
	protected $data = array();
	/**
	 * @var Validator
	 */
	protected $validator;
	/**
	 * Callbacks to setup a certain property
	 *
	 * @var Closure[]
	 */
	protected $setters = array();
	/**
	 * Whether it is allowed to change this instance data.
	 * @var bool
	 */
	private $_frozen = false;
	/**
	 * Table relationships
	 * $children: array(
	 * 	<ID> => array(<namespace-qualified ActiveRecord class name>, <remote_column>)
	 * )
	 * $parents: array(
	 * 	<ID> => array(<namespace-qualified ActiveRecord class name>, <local_column>)
	 * )
	 * $neighbors: array(
	 * 	<ID> => array(
	 * 			<namespace-qualified ActiveRecord class name>,
	 * 			<intermediate_table>, 
	 * 			<this_table_id>, 
	 * 			<that_table_id>,
	 * 			<intermediate_table_cols>// optional
	 * 		)
	 * )
	 * @var array
	 */
	static protected $children = array(), 
		$parents = array(),
		$neighbors = array();
	
	/**-@*/
	
	/**
	 * @return string
	 */
	static function getTable()
	{
		return static::$table;
	}
	/**
	 * @return string[]
	 */
	static function getColumns()
	{
		return array_keys(static::$defaults);
	}
	/**
	 * @return string
	 */
	static function getPk()
	{
		return static::$pk;
	}
	/**
	 * Insert a new row
	 * @param array $row
	 * @return int new row ID
	 */
	static function insert($row)
	{
		if (isset($row[static::$pk]))
		{
			unset($row[static::$pk]);
		}
		
		if (static::insertRow($row)) {
			return (int) static::getConnection()->lastInsertId(static::getSequence());
		}
	}
	/**
	 * @static
	 * @param  $row
	 * @return int
	 */
	static protected function insertRow($row)
	{
		$sql = 'INSERT INTO ' . (static::$table) . ' (' . implode(', ', array_keys($row)) .
				') VALUES (?' . str_repeat(', ?', count($row) - 1) . ')';
		$stmt = static::statement($sql, array_values($row));
		return $stmt->rowCount();
	}
	/**
	 * Update a row specified by id $id
	 * @param array $row
	 * @param int $id (optional, may be specified as a $row member)
	 * @return bool
	 */
	static function update($row, $id = null)
	{
		if ((! $id) && ($row[static::$pk]))
		{
			$id = (int) $row[static::$pk];
		}
		
		if (! $id)
		{
			return false;// @todo exception throw?
		}
		
		$updates = array();
		foreach (array_keys($row) as $field)
		{
			$updates[] = $field . ' = ?';
		}
		
		$sql = 'UPDATE ' . (static::$table) . ' SET ' . implode(', ', $updates) . 
				' WHERE ' . (static::$pk) . ' = ?';
		$data = array_values($row);
		$data[] = $id;
		static::statement($sql, $data);
		return $id;
	}
	/**
	 * Delete row specified by $id
	 * @param int | int[] $id
	 * @return int Number of rows deleted
	 */
	static function deleteRow($id)
	{
		return (bool) static::statement('DELETE FROM ' . (static::$table) . ' WHERE ' . (static::$pk) . ' = ?', array($id))->rowCount();
	}
	/**
	 * DELETE rows specified in $where
	 */
	static function deleteAll($where)
	{
		$bound_vars = $and = array();
		foreach ((array) $where as $key => $val)
		{
			if (is_string($key))
			{
				$and[] = $key;
				if (null !== $val)
				{
					$val = (array) $val;
				}
				$bound_vars = array_merge($bound_vars, $val);
			} else {
				$and[] = $val;
			}
		}
		return (bool) static::statement('DELETE FROM ' . (static::$table) . ' WHERE (' . implode(') AND (', $and) . ')', $bound_vars)->rowCount();
	}
	/**
	 * @param int $id
	 * @return Select
	 */
	static function find($id)
	{
		return static::select()->where(static::$table . '.' . static::$pk . ' = ?', $id);
	}
	/**
	 * @param int[]
	 * @return Select
	 */
	static function findMany(array $id_list)
	{
		return static::select()->where(static::$table . '.' . static::$pk . ' IN (?)', $id_list);
	}
	/**
	 * @param array $where conditions for the WHERE SQL clause
	 * @param string $cols
	 * @param int | array $fetchMode - per-call fetching mode overriding static::$fetchMode value
	 * @return Select
	 */
	static function findAll($where = null, $cols = "*", $fetchMode = null)
	{
		$s = static::select($cols);
		foreach ((array) $where as $key => $value)
		{
			if (is_numeric($key))
			{
				$args = array($value);
			} else {
				$args = (array) $value;
				array_unshift($args, $key);
			}
			call_user_func_array(array($s, 'where'), $args);
		}
		return $s;
	}
	/**
	 * @return int
	 */
	static function count($where = null, $expr = '*')
	{
		return static::findAll($where)->count($expr);
	}
	/**
	 * @param PDO $c
	 */
	static function setConnection($c)
	{
		static::$connection = $c;
	}
	/**
	 * @return Select
	 */
	static protected function select($cols = '*')
	{
		if ('*' == $cols) {
			$cols = static::$table . '.*';
		}
		$s = new Select(static::$table, $cols);
		return $s->setConnection(static::getConnection())->setFetchMode(PDO::FETCH_CLASS, get_called_class());
	}
	/**
	 * Constructor
	 * 
	 * @param int | array $arg (optional) either instance ID -
	 * 		to be immediately loaded from DB, or an array of data to be immediately imported.
	 */
	function __construct($arg = null)
	{
		/* some data may already be loaded during PDO::FETCH_CLASS/PDO::FETCH_OBJECT fetching */
		$this->data = array_merge(static::$defaults, (array) $this->data);
		if (! array_key_exists(static::$pk, $this->data))
		{
			$this->data[static::$pk] = null;
		}
		
		if (null !== $arg)
		{
			if (is_scalar($arg))
			{
				if (! $this->load($arg))
				{
					throw new NotFoundException();
				}
			} else {
				$this->import($arg);
			}
		}
	}
	/**
	 * Import data from array
	 * @param array $data
	 * @param bool $includeId Whether to import the 'id' element
	 */
	function import($data, $includeId = true)
	{
		if ($data instanceof self)
		{
			$data = $data->toArray();
		}
		
		if ((! $includeId) && array_key_exists(static::$pk, $data))
		{
			unset($data[static::$pk]);
		}

		foreach ($data as $key => $value)
		{
			$this->__set($key, $value);
		}
	}
	/**
	 * Import ONLY selected keys from $data 
	 *
	 * @param array $data
	 * @param array $keys
	 */
	function importSelected($data, array $keys)
	{
		return $this->import(array_intersect_key($data, array_flip($keys)));
	}
	/**
	 * Get the PRIMARY KEY value
	 * @return int
	 */
	function id()
	{
		return $this->data[static::$pk];
	}
	/**
	 * @return array
	 */
	function toArray()
	{
		return $this->data;
	}
	/**
	 * @return string
	 */
	function json()
	{
		return json_encode($this->data);
	}
	/**
	 * Get objects bound to $this as 1-to-N
	 * @param string $relation_id
	 * @return Select
	 */
	protected function getChildren($relation_id)
	{
		list($child_class_name, $remote_col) = static::$children[$relation_id];
		return call_user_func(array(Km::className($child_class_name), 'findAll'), array($remote_col . ' = ?' => (int) $this->id));
	}
	/**
	 * Get objects bound to $this as N-to-1
	 * @param string $relation_id
	 * @return Select
	 */
	protected function getParent($relation_id)
	{
		list($parent_class_name, $local_col) = static::$parents[$relation_id];
		return call_user_func(array(Km::className($parent_class_name), 'find'), (int) $this->$local_col);
	}
	/**
	 * Get objects bound to $this as N-to-M
	 * @param string $relation_id
	 * @return Select
	 */
	protected function getNeighbors($relation_id)
	{
		return static::getNeighborRelation($relation_id)->get((int) $this->id());
	}
	/**
	 * @param string $relation_id
	 * @return ActiveRecord_NeighborRelation
	 */
	static protected function getNeighborRelation($relation_id)
	{
		if (static::$neighbors[$relation_id] instanceof ActiveRecord_NeighborRelation)
		{
			return static::$neighbors[$relation_id];
		} else {
			@list($neighbor_class_name, 
				$intermediate, 
				$this_table_id, 
				$that_table_id, 
				$intermediate_table_cols) = static::$neighbors[$relation_id];
			return static::$neighbors[$relation_id] = new ActiveRecord_NeighborRelation(
				Km::className($neighbor_class_name), 
				$intermediate, 
				$this_table_id, 
				$that_table_id, 
				$intermediate_table_cols,
				static::getConnection()
			);
		}
	}
	/**
	 * Add a N-to-M relationship
	 * @param string $relation_id
	 * @param int $that_id
	 * @param array $values Additional values for the intermediate table row (col => value)
	 * @return ActiveRecord $this
	 */
	protected function addNeighbor($relation_id, $that_id, array $values = array())
	{
		static::getNeighborRelation($relation_id)->add($this->id(), $that_id, $values);
		return $this;
	}
	/**
	 * Set N-to-M relationships with a certain table
	 * @param string $relation_id
	 * @param array $neighbors An array specifying rows in the outer table and the 
	 * 		intermediate. If a value is numeric, it is treated as remote-table-ID.
	 * 		If a value is an array, then the KEY is treated as remote-table-ID, while
	 * 		the value is considered additional cols-values for the intermediate table.
	 * @return ActiveRecord $this
	 */
	protected function setNeighbors($relation_id, $neighbors)
	{
		return $this->clearNeighbors($relation_id)->addNeighbors($relation_id, $neighbors);
	}
	/**
	 * Add N-to-M relationships with a certain table
	 * @see setNeighbors()
	 * @return ActiveRecord $this
	 */
	protected function addNeighbors($relation_id, $neighbors)
	{
		static::getNeighborRelation($relation_id)->addMany($this->id(), $neighbors);
		return $this;
	}
	/**
	 * Update N-to-M relationship (the intermediate table)
	 */
	protected function updateNeighborRelation($relation_id, $that_id, array $values)
	{
		static::getNeighborRelation($relation_id)->update($this->id(), $that_id, $values);
		return $this;
	}
	/**
	 * Remove a certain M-to-M relationship with a certain table.
	 * @param string $relation_id
	 * @param int $that_table_id remote table ID
	 * @return ActiveRecord $this
	 */
	protected function removeNeighbor($relation_id, $that_table_id)
	{
		static::getNeighborRelation($relation_id)->remove($this->id(), $that_table_id);
		return $this;
	}
	/**
	 * Remove M-to-M relationships with a certain table.
	 * @param string $relation_id
	 * @return ActiveRecord $this
	 */
	protected function clearNeighbors($relation_id)
	{
		static::getNeighborRelation($relation_id)->clear($this->id());
		return $this;
	}

	function isNew()
	{
		return empty($this->data[static::$pk]);
	}

	/**
	 * @param array $row
	 * @return int new row ID
	 */
	function save($exceptionClass = null)
	{
		if (! $this->_check()) {
			if ($exceptionClass) {
				throw new $exceptionClass(implode(', ', $this->getErrorList()));
			} else {
				return false;
			}
		}

		$rowData = array_intersect_key($this->data, static::$defaults);
		$ret = $this->isNew() ?
				($this->data[static::$pk] = static::insert($rowData)) :
				static::update($rowData, $this->data[static::$pk]);
		if ((! $ret) && $exceptionClass) {
			throw new \Exception(get_class($this) . ': save failed for unknown reason');
		}
		return $ret;
	}
	/**
	 * Increment selected column
	 *
	 * @param string $column
	 * @param int | float $inc
	 * @param array $additionalFields key-value pairs, for other fields to be updated (NOT incremented!)
	 * @return mixed the field value after increment
	 */
	function increment($column, $inc = 1, array $additionalFields = array())
	{
		$fields = array($column . ' = ' . $column . ' + (?)');
		$values = array($inc);
		foreach ($additionalFields as $col => $value) {
			$fields[] = $col . ' = ?';
			$values[] = $value;
		}
		$sql = 'UPDATE ' . static::$table . ' SET ' . implode(',', $fields) . ' WHERE ' . static::$pk . ' = ?';
		$values[] = $this->id();
		static::getConnection()->prepare($sql)->execute($values);
		return $this->$column = $this->$column + $inc;
	}
	
	function delete()
	{
		if (empty($this->data[static::$pk]))
		{
			return false;
		}
		
		return static::deleteRow($this->data[static::$pk]);
	}
	
	/**
	 * Loads data from a table row with id=$id
	 *
	 * @param int $id
	 * @return array row data on success or bool false if no results can be found
	 */
	function load($id)
	{
		$this->reset();
		if ($found = static::find($id)->fetchOne())
		{
			return $this->data = $found->toArray();
		} else {
			return false;
		}
	}
	function reload()
	{
		$this->load($this->id);
	}
	/**
	 * Loads data from a table row with $colname=$colvalue
	 *
	 * @param mixed $colname
	 * @param mixed $colvalue
	 * @return array row data on success or bool false if no results can be found
	 */
	function loadByColumn($colname, $colvalue)
	{
		return $this->loadByFilter(array($colname . ' = ?' => $colvalue));
	}
	/**
	 * @param array|string $filter
	 */
	function loadByFilter($filter, $order = null) {
		$this->reset();
		if ($row = static::findAll($filter, $order)->fetchOne())
		{
			$this->import($row);
			return $row;
		} else {
			return false;
		}
	}
	/**
	 * Reset data to defaults
	 */
	function reset()
	{
		$this->data = static::$defaults;
		$this->data[static::$pk] = null;
	}
	/**
	 * Forbid changes
	 */
	function freeze()
	{
		$this->_frozen = true;
	}
	/**
	 * allow changes
	 */
	function unfreeze()
	{
		$this->_frozen = false;
	}
	/**
	 * Easy read access to object properties
	 *
	 * @param string $name
	 * @return mixed
	 */
	function __get($name)
	{
		return array_key_exists($name, $this->data) ? $this->data[$name] : null;
	}
	/**
	 * Easy write access to object properties
	 *
	 * @param string $name
	 * @param mixed $value
	 */
	function __set($name, $value)
	{
		if ($this->_frozen) {
			throw new LogicException('This instance of ' . __CLASS__ . ' is frozen and thus cannot be changed!');
		}

		if (array_key_exists($name, $this->setters))
		{
			$method = $this->setters[$name];
			$this->data[$name] = $this->{$this->setters[$name]}($value);
		} else {
			$this->data[$name] = $value;
		}
	}
	/**
	 * Get validator for the row to be inserted/updated
	 *
	 * @return Validator
	 */
	final function validator()
	{
		if (null === $this->validator)
		{
			$this->validator = $this->createValidator();
		}
		return $this->validator;
	}
	/**
	 * Shortcut for $this->validator()->result()->getErrorList();
	 * @return array
	 */
	final function getErrorList()
	{
		return $this->validator()->result()->getErrorList();
	}
	/**
	 * A commonly used setter
	 * @param mixed $value
	 * @return mixed
	 */
	function nullSetter($value)
	{
		return empty($value) ? null : $value;
	}
	/**
	 * A commonly used setter
	 * @param mixed $value
	 * @return mixed
	 */
	function zeroSetter($value)
	{
		return empty($value) ? 0 : $value;
	}
	/**
	 * A commonly used validator-filter
	 * @return Closure
	 */
	function getDateFilter()
	{
		return function($date) {
			if ($ts = strtotime($date))
			{
				return date("Y-m-d", $ts);
			}
			return false;
		};
	}
	/**
	 * A commonly used validator-filter
	 * @return Closure
	 */
	function getDateTimeFilter()
	{
		return function($date) {
			if ($ts = strtotime($date))
			{
				return date("Y-m-d H:i:s", $ts);
			}
			return false;
		};
	}
	/**
	 * Create validator for the row to be inserted/updated
	 * @return Validator
	 */
	protected function createValidator()
	{
		return new Validator();
	}
	/**
	 * Filter a column to be unique.
	 * @param string $col
	 * @param mixed $value
	 * @return mixed value of $value if it is unique, bool FALSE otherwise
	 */
	protected function getUniqueFilter($col)
	{
		$pk = static::$pk;
		$instance = $this;
		return function($value) use ($col, $pk, $instance) {
			$where = array($col . ' = ?' => $value);
			$id = $instance->id();
			if (! empty($id))
			{
				$where[$pk . ' != ?'] = $id;
			}
	
			if (call_user_func(array(get_class($instance), 'count'), $where))
			{
				return false;
			}
	
			return $value;
		};
	}
	/**
	 * it is a common case when for validation, we have to be sure
	 * that an object exists in DB - that is specified by ID.
	 * This method returns an appropriate finder - a Closure object
	 * @param string $class_name the ActiveRecord-class name
	 * @return Closure
	 */
	protected function getFinder($class_name)
	{
		return function($id) use ($class_name) {
			if ($object = call_user_func(array($class_name, 'find'), $id))
			{
				return $object->id;
			} else {
				return false;
			}
		};
	}
	/**
	 * @return bool
	 */
	protected function _check()
	{
		$result = $this->validator()->check($this->data);
		if ($result->ok())
		{
			$this->data = $result->export();
			return true;
		}
		else
			return false;
	}
	/**
	 * @return PDO
	 */
	static function getConnection()
	{
		return static::$connection ?: (static::$connection = Km::getDbConnection());
	}
	/**
	 * @param string $sql
	 * @param array $data
	 * @return PDOStatement
	 */
	static protected function statement($sql, $data)
	{
		$s = static::getConnection()->prepare($sql);
		foreach ($data as $n => $value)
		{
			if (is_bool($value))
			{// explicitly specify type
				$s->bindValue($n + 1, $value, PDO::PARAM_BOOL);
			} else {// rely on PDO
				$s->bindValue($n + 1, $value);
			}
		}
		$s->execute();
		$s->setFetchMode(PDO::FETCH_CLASS, get_called_class());
		return $s;
	}
	static private function getSequence()
	{
		return static::$sequence ?: static::$table . '_' . static::$pk . '_seq';
	}
	/**
	 * ArrayAccess methods
	 */
	function offsetExists($name)
	{
		return array_key_exists($name, $this->data);
	}
	function offsetGet($name)
	{
		return $this->__get($name);
	}
	function offsetSet($name, $value)
	{
		return $this->__set($name, $value);
	}
	function offsetUnset($name)
	{
		return $this->__set($name, null);
	}
}